/*
 * Copyright 2007-2008 Andrew O'Malley
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */


package ws.quokka.plugin.subversion;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.types.Commandline;
import org.tmatesoft.svn.cli.SVNArgument;
import org.tmatesoft.svn.cli.SVNCommand;
import org.tmatesoft.svn.cli.SVNCommandLine;
import org.tmatesoft.svn.cli.SVN;
import org.tmatesoft.svn.core.SVNErrorMessage;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNURL;
import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager;
import org.tmatesoft.svn.core.auth.ISVNAuthenticationProvider;
import org.tmatesoft.svn.core.auth.SVNAuthentication;
import org.tmatesoft.svn.core.auth.SVNUserNameAuthentication;
import org.tmatesoft.svn.core.internal.io.dav.DAVRepositoryFactory;
import org.tmatesoft.svn.core.internal.io.fs.FSRepositoryFactory;
import org.tmatesoft.svn.core.internal.io.svn.SVNRepositoryFactoryImpl;
import org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager;
import org.tmatesoft.svn.core.internal.wc.admin.SVNAdminArea;
import org.tmatesoft.svn.core.internal.wc.admin.SVNEntry;
import org.tmatesoft.svn.core.internal.wc.admin.SVNWCAccess;
import org.tmatesoft.svn.core.wc.DefaultSVNRepositoryPool;
import org.tmatesoft.svn.core.wc.SVNClientManager;
import ws.quokka.core.bootstrap_util.Assert;
import ws.quokka.core.bootstrap_util.Reflect;
import ws.quokka.core.bootstrap_util.VoidExceptionHandler;
import ws.quokka.core.plugin_spi.support.AbstractPlugin;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.lang.reflect.Method;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.Arrays;
import java.util.Collections;


/**
 *
 */
public class SubversionPlugin extends AbstractPlugin {
    //~ Static fields/initializers -------------------------------------------------------------------------------------

    static List SYSTEM_PROPERTIES = Arrays.asList(new String[] {"ssh2.username", "ssh2.key", "ssh2.passphrase",
        "ssh2.password", "ssh2.port", "ssh2.author"});

    //~ Methods --------------------------------------------------------------------------------------------------------

    public void cleanup() {
        execute("cleanup");
    }

    public void checkout() {
        // Supports only a single URL for checkout
        List paths = new ArrayList();
        paths.add(properties().getString("url"));

        String path = properties().getString("path", null);

        if (path != null) {
            paths.add(path);
        }

        execute("checkout", getArguments(), paths);
    }

    public File getWorkingCopyRoot() {
        File workingCopy = properties().getFile("workingCopyOverride", null);

        if (workingCopy == null) {
            workingCopy = getProject().getBaseDir();
        }

        return workingCopy;
    }

    public void commit() throws SVNException {
        String message = getProject().getProperty("q.versioncontrol.commit.message");
        Assert.isTrue(message != null, message + "q.versioncontrol.commit.message must be set");
        List arguments = getArguments();
        addArgument(arguments, SVNArgument.MESSAGE);
        arguments.add(message);

        execute("commit", arguments, toList(getWorkingCopyRoot().getAbsolutePath()));

        // This property controls whether the working copy is updated to the version just committed.
        // This allows the working copy to pass checks for mixed working copies prior to branching.
        if ("true".equals(getProject().getProperty("q.versioncontrol.commit.updateAfterCommit"))) {
            log().verbose("Updating after commit is enabled");
            String version = getSvnVersion();
            int pos = version.indexOf(':');
            if (pos != -1) {
                int from = Integer.parseInt(version.substring(0, pos));
                int to = Integer.parseInt(version.substring(pos + 1));
                if (from != to - 1) {
                    log().warn("Expected a mixed working copy separated by a single revision: " + version);
                }

                arguments = new ArrayList();
                arguments.add("--revision");
                arguments.add(Integer.toString(to));
                execute("update", arguments, toList(getWorkingCopyRoot().getAbsolutePath()));
            }
        }
    }

    public void update() {
        execute("update");
    }

    public void tag() throws SVNException {
        copy("tags", "q.versioncontrol.tag.name", "q.versioncontrol.tag.message");
    }

    private void copy(String type, String property, String messageProperty) throws SVNException {
        String name = getProject().getProperty(property);
        Assert.isTrue(name != null, property + " must be set");

        String message = getProject().getProperty(messageProperty);
        Assert.isTrue(name != null, messageProperty + " must be set");

        // Check the working copy doesn't contain un-committed changes
        boolean verifyCommitted = properties().getBoolean("verifyCommitted", true);

        if (verifyCommitted) {
            verifyCommitted();
        }

        File workingCopy = getWorkingCopyRoot();
        String url = getRootUrl(workingCopy, type) + name;
        List paths = new ArrayList();
        paths.add(workingCopy.getAbsolutePath());
        paths.add(url);

        // Add the message
        List arguments = getArguments();
        addArgument(arguments, SVNArgument.MESSAGE);
        arguments.add(message);

        execute("copy", arguments, paths);
    }

    private String getRootUrl(File dir, String type) throws SVNException {
        SVNURL url = getURL(dir);

        String tagsRootUrl = properties().getString("rootUrl", null);

        if (tagsRootUrl != null) {
            return url.removePathTail().toString() + (tagsRootUrl.startsWith("/") ? "" : "/") + tagsRootUrl
                    + (tagsRootUrl.endsWith("/") ? "" : "/");
        }

        // Look for either trunk, branches or tags in path and assume tags is a sibling
        StringBuffer path = new StringBuffer("/");
        StringTokenizer tokenizer = new StringTokenizer(url.getPath(), "/");

        while (tokenizer.hasMoreTokens()) {
            String token = tokenizer.nextToken();

            if (token.equals("trunk") || token.equals("branches") || token.equals("tags")) {
                path.append(type).append("/");

                return url.setPath(null, true).toString() + path.toString();
            } else {
                path.append(token).append("/");
            }
        }

        throw new BuildException("Cannot automatically determine the '" + type
                + "' root url. Specify it explicitly via the rootUrl property");
    }

    private SVNURL getURL(File dir) throws SVNException {
        SVNWCAccess access = SVNWCAccess.newInstance(null);

        try {
            SVNAdminArea wc = access.probeOpen(dir, false, 0);
            SVNEntry entry = wc.getEntry(wc.getThisDirName(), false);
            Assert.isTrue(entry != null, "Project base directory is not under version control: " + dir.getPath());

            return entry.getSVNURL();
        } finally {
            access.close();
        }
    }

    public void branch() throws SVNException {
        copy("branches", "q.versioncontrol.branch.name", "q.versioncontrol.branch.message");
    }

    public void status() {
        execute("status");
    }

    public void revert() {
        List arguments = getArguments();
        if (!containsArgument(arguments, SVNArgument.RECURSIVE)) {
            addArgument(arguments, SVNArgument.RECURSIVE);
        }

        execute("revert");
    }

    private boolean containsArgument(List arguments, SVNArgument argument) {
        for (Iterator i = arguments.iterator(); i.hasNext();) {
            String arg = (String) i.next();
            for (Iterator j = argument.names(); j.hasNext();) {
                String name = (String) j.next();
                if (arg.equals(name)) {
                    return true;
                }
            }
        }
        return false;
    }

    private void removeArgument(List arguments, SVNArgument argument) {
        for (Iterator i = arguments.iterator(); i.hasNext();) {
            String arg = (String) i.next();
            for (Iterator j = argument.names(); j.hasNext();) {
                String name = (String) j.next();
                if (arg.equals(name)) {
                    i.remove();
                }
            }
        }
    }

    private void addArgument(List arguments, SVNArgument argument) {
        arguments.add(argument.names().next());
    }

    private void verifySingleRevision() throws SVNException {
        String id = getSvnVersion();
        Assert.isTrue((id.indexOf(':') == -1) && (id.indexOf('S') == -1),
                "The working copy contains mixed revisions or is switched: svnversion=" + id);
    }

    private String getSvnVersion() throws SVNException {
        return SVNClientManager.newInstance().getWCClient().doGetWorkingCopyID(getWorkingCopyRoot(), null, false);
    }

    public void verifyReleasable() throws IOException, SVNException {
        verifySingleRevision();

        File projectFile = new File(getResources().getProject().getProperty("q.project.file")).getCanonicalFile();

        List lines = getStatusLines();

        // TODO: Allow a list of excluded files.
        String message = "A releasable project must have only 1 modified file, the project file. "
                + "The project version should be the only modification, set to the version of the new release.";
        Assert.isTrue(lines.size() == 1, message);

        String line = (String) lines.get(0);
        Assert.isTrue(("M".indexOf(line.charAt(0)) != -1) && (line.length() > 8), message);

        // Matching status files to module files is tricky due to externals.
        // For now, just match the file name, ignoring paths.
        String name = line.substring(line.lastIndexOf(File.separatorChar) + 1);
        Assert.isTrue(projectFile.getName().equals(name), message);

        StringTokenizer oldTokenizer = null;
        StringTokenizer newTokenizer = null;
        List diff = executeTrapOutput("diff", Collections.EMPTY_LIST, toList(projectFile.getAbsolutePath()));

        for (Iterator i = diff.iterator(); i.hasNext();) {
            String diffLine = (String) i.next();

            if (diffLine.startsWith("-") && !diffLine.startsWith("---")) {
                Assert.isTrue(oldTokenizer == null, message);
                oldTokenizer = new StringTokenizer(diffLine.substring(1), " ='\"");
            } else if (diffLine.startsWith("+") && !diffLine.startsWith("+++")) {
                Assert.isTrue(newTokenizer == null, message);
                newTokenizer = new StringTokenizer(diffLine.substring(1), " ='\"");
            }
        }

        Assert.isTrue((oldTokenizer != null) && (newTokenizer != null), message);

        boolean versionLast = false;
        String oldVersion = null;
        String newVersion = null;

        while (oldTokenizer.hasMoreTokens()) {
            String oldToken = oldTokenizer.nextToken();
            Assert.isTrue(newTokenizer.hasMoreTokens(), message);

            String newToken = newTokenizer.nextToken();

            if (versionLast) {
                oldVersion = oldToken;
                newVersion = newToken;
            } else {
                Assert.isTrue(oldToken.equals(newToken), message);
            }

            versionLast = oldToken.equals("version") && newToken.equals("version");
        }

        log().info("Version changed from '" + oldVersion + "' to '" + newVersion + "'");
        getProject().setProperty("q.versioncontrol.verifyreleasable.oldVersion", oldVersion);
        getProject().setProperty("q.versioncontrol.verifyreleasable.newVersion", newVersion);
    }

    private List toList(String string) {
        List list = new ArrayList(1);
        list.add(string);

        return list;
    }

    private List executeTrapOutput(String command, List arguments, List paths) {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        execute(command, arguments, paths, new PrintStream(out), System.err);

        StringTokenizer tokenizer = new StringTokenizer(out.toString(), System.getProperty("line.separator"));
        List lines = new ArrayList();

        while (tokenizer.hasMoreTokens()) {
            String line = tokenizer.nextToken();
            System.out.println(line);
            lines.add(line);
        }

        return lines;
    }

    public void verifyCommitted() throws SVNException {
        log().verbose("Verifying all changes to the working copy are committed");
        verifySingleRevision();
        Assert.isTrue(getStatusLines().size() == 0,
                "The working copy contains uncommitted entries. See the output above for more information");
    }

    private List getStatusLines() {
        List arguments = getArguments();
        removeArgument(arguments, SVNArgument.VERBOSE);
        addArgument(arguments, SVNArgument.QUIET);

        return executeTrapOutput("status", arguments, toList(getWorkingCopyRoot().getAbsolutePath()));
    }

    public void execute(String command) {
        execute(command, getArguments(), toList(getWorkingCopyRoot().getAbsolutePath()), System.out, System.err);
    }

    public void execute(String command, List arguments, List paths) {
        execute(command, arguments, paths, System.out, System.err);
    }

    public void execute(final String command, final List arguments, final List paths, final PrintStream out, final PrintStream err) {
        log().verbose("Executing svn: command=" + command + ", arguments=" + arguments + ", paths=" + paths);
        new VoidExceptionHandler() {
            public void run() throws Exception {
                // Set system properties (currently used as a mechansim for passing through ssh parameters)
                for (Iterator i = SYSTEM_PROPERTIES.iterator(); i.hasNext();) {
                    String property = (String) i.next();
                    String value = properties().getString(property, null);

                    if (value != null) {
                        System.setProperty("svnkit." + property, value);
                    }
                }

                Field field = new Reflect().getField(SVN.class, "ourArguments");
                Set validArguments = (Set) new Reflect().get(field, null);

                // Combine the arguments
                List args = new ArrayList();
                args.add(command);
                args.addAll(arguments);
                args.addAll(paths);
                String[] argsArray = (String[]) args.toArray(new String[args.size()]);

                SVNCommandLine commandLine = new SVNCommandLine(argsArray, validArguments);
                String commandName = commandLine.getCommandName();
                SVNCommand command = SVNCommand.getCommand(commandName);

                DAVRepositoryFactory.setup();
                SVNRepositoryFactoryImpl.setup();
                FSRepositoryFactory.setup();

                command.setCommandLine(commandLine);

                SVNClientManager clientManager = null;

                try {
                    // Here we use reflection to patch the DumbAuthenticationProvider so that it can handle the
                    // svnkit.ssh2.author system property properly. This will be removed as soon as
                    // http://svnkit.com/tracker/view.php?id=210 is fixed
                    Reflect reflect = new Reflect();
                    clientManager = (SVNClientManager) reflect.invoke(command, "getClientManager", new Object[]{});

                    DefaultSVNRepositoryPool pool = (DefaultSVNRepositoryPool) reflect.get(reflect.getField(
                            SVNClientManager.class, "myRepositoryPool"), clientManager);
                    DefaultSVNAuthenticationManager authManager = (DefaultSVNAuthenticationManager) reflect.get(reflect
                            .getField(DefaultSVNRepositoryPool.class, "myAuthManager"), pool);
                    ISVNAuthenticationProvider[] providers = (ISVNAuthenticationProvider[]) reflect.get(reflect
                            .getField(DefaultSVNAuthenticationManager.class, "myProviders"), authManager);
                    providers[0] = new PatchedDumbAuthenticationProvider(providers[0]);

                    command.run(out, err);
                } finally {
                    if (clientManager != null) {
                        clientManager.shutdownConnections(true);
                    }
                }
            }
        };
    }

    /**
     * Converts properties into command line arguments
     */
    private List getArguments() {
        String arguments = properties().getString("arguments", null);
        if (arguments == null) {
            return new ArrayList();
        }

        Commandline commandline = new Commandline(arguments);
        return Arrays.asList(commandline.getArguments());
    }

    //~ Inner Classes --------------------------------------------------------------------------------------------------

    public static class PatchedDumbAuthenticationProvider implements ISVNAuthenticationProvider {
        private Object dumb;
        private Class dumbClass;

        public PatchedDumbAuthenticationProvider(Object dumb) {
            this.dumb = dumb;
            dumbClass = dumb.getClass();
        }

        public SVNAuthentication requestClientAuthentication(String kind, SVNURL url, String realm,
            SVNErrorMessage errorMessage, SVNAuthentication previousAuth, boolean authMayBeStored) {
            Reflect reflect = new Reflect();

            if (previousAuth == null) {
                if (ISVNAuthenticationManager.USERNAME.equals(kind)) {
                    String myUserName = (String) reflect.get(reflect.getField(dumbClass, "myUserName"), dumb);
                    boolean myIsStore = ((Boolean) reflect.get(reflect.getField(dumbClass, "myIsStore"), dumb))
                            .booleanValue();

                    if ((myUserName == null) || "".equals(myUserName)) {
                        String userName = System.getProperty("svnkit.ssh2.author",
                                System.getProperty("javasvn.ssh2.author"));

                        if (userName != null) {
                            return new SVNUserNameAuthentication(userName, myIsStore); // BUG FIXED LINE
                        }

                        return null;
                    }

                    return new SVNUserNameAuthentication(myUserName, myIsStore);
                }
            }

            Method method = reflect.getMethod(dumbClass, "requestClientAuthentication",
                    new Class[]{
                            String.class, SVNURL.class, String.class, SVNErrorMessage.class, SVNAuthentication.class,
                            Boolean.TYPE
                    });

            return (SVNAuthentication) reflect.invoke(method, dumb,
                    new Object[]{
                            kind, url, realm, errorMessage, previousAuth, (authMayBeStored) ? Boolean.TRUE : Boolean.FALSE
                    });
        }

        public int acceptServerAuthentication(SVNURL url, String r, Object serverAuth, boolean resultMayBeStored) {
            return ACCEPTED;
        }
    }
}
